Skip to content

fix(app-shell): render the envelope's producer-marked userMessage in the Studio package lookup - #7960

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-7938-fetchfullpackage-usermessage
Sep 6, 2026
Merged

fix(app-shell): render the envelope's producer-marked userMessage in the Studio package lookup#7960
os-sam merged 2 commits into
mainfrom
claude/issue-7938-fetchfullpackage-usermessage

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #7938

fetchFullPackage reads the ADR-0112 failure envelope for the words it shows the author,
and read only error.message and error.code. The platform's package door also emits
error.userMessage — the text a producer marked at throw time as addressed to the end
user. This teaches the reader to prefer it.

Verified on the shipping tree at 15c57f85a.

The field really does arrive — measured in the producer, not assumed

The card's evidence chain has a leg in another repo, so it was checked before a line was
written. GET /api/v1/packages is served by the direct-mount registrar,
packages/rest/src/package-routes.ts. Its sendThrownError withholds the prose in the
5xx band:

const thrown = resolveThrownHttpError(error);
// The dispatcher twin's expression, byte for byte — one rule, two doors.
const message = thrown.status >= 500 && looksLikeInternalErrorLeak(thrown.message)
  ? INTERNAL_ERROR_MESSAGE
  : thrown.message;

and then spreads the marked channel onto the wire beside that local, untouched by it:

const extra = {
  ...(thrown.details ? { details: thrown.details } : {}),
  ...(declaredCode !== undefined ? { declaredCode } : {}),
  ...(thrown.userMessage !== undefined ? { userMessage: thrown.userMessage } : {}),
};

That door's own note states the rule, and it is the answer to the question the card asked:

⚠️ NOT withheld on the sanitised 5xx above, and this one needs no judgement call: the
withhold rewrites a LOCAL message const, and looksLikeInternalErrorLeak is only ever
handed thrown.message, so thrown.userMessage is never an input to it. A marked text
is the producer's deliberate statement to the caller at any status — the ruling that
created the channel made it status-agnostic on purpose.

sendError (packages/types/src/response-envelope.ts) nests extra under error:

res.status(status).json({ success: false, error: { code, message, ...extra } });

which is exactly the error.userMessage path this PR reads. The framework pins the pair
in packages/rest/src/package-door-user-message.test.ts §4, asserting
error.message === INTERNAL_ERROR_MESSAGE and the marked text intact on the same body.

So the premise holds: in the 5xx band message is replaced by the generic sentence and
userMessage is not withheld with it.
No cross-repo change is produced by this PR; the
framework was read only.

Why userMessage outranks message, and why not only in the 5xx band

The priority is the contract's. The envelope writer's own words:

the text a producer marked, AT THROW TIME, as addressed to the END USER. Presence IS the
marking — a consumer that sees the field renders it verbatim and keeps its generic
substitution for everything unmarked.

The card offered a 5xx-only variant. It is declined deliberately, and the reason is in the
producing door: it applies no status condition to this channel, on purpose. A consumer
that honoured the mark in one band only would re-create on the reading end precisely the
divergence that door refused to create on the writing end. A 4xx message is already
caller-facing by design — but when a producer also marked a text there, the mark is the
more specific answer to "what should this person read", and the diagnostic it displaces is
not lost to diagnosis: code still travels.

This is also not a tolerant alias ladder. These are two declared fields with different
meanings, not two spellings of one fact.

Three combinations, all pinned

Both fields are optional and independent. The new pin file drives all three, not only the
one that motivated the card — reading only the "both present" case is exactly what let
this survive.

combination before after red when reverted?
message only (the common unmarked refusal) the sentence (CODE) unchanged, byte for byte no — deliberate
userMessage only HTTP 503 the marked sentence (CODE) yes
both (the live 5xx case) Internal server error (CODE) the marked sentence (CODE) yes
neither HTTP 503 unchanged no

The first row is load-bearing: it is the pin that stops "prefer userMessage" from being
implemented as "read userMessage instead", which would have blanked every refusal
the platform serves today.

code composes orthogonally, and that is pinned too: it is appended to whichever prose
won; it never rescues a body carrying no prose (code alone still yields the bare status);
and a non-string or empty-string userMessage is not a mark, so it falls through to
message. The producer's own declaredUserMessage already applies that non-empty rule —
the reader does not depend on it doing so.

The four callers of this endpoint, named individually

This is a shared read, so the beneficiaries were measured rather than inherited from the
card — and the measurement corrects the card on one point. The card described "four
beneficiaries" of this fix. The four named are the four callers of GET /api/v1/packages
on this surface (which is what the openManage docblock says), and they split two ways:

caller route behaviour change
Switcher list (mount effect + onManageChanged head) packages-io fetchPackages none — see below
Writability courtesy gate (ADR-0124 D1) packages-io fetchPackages none — see below
Namespace lookup (framework#2694) packages-io fetchPackages none — see below
fetchFullPackage its own envelope reader — the site changed here changed, for both of its own callers

The first three go through fetchPackages in packages-io.ts, which is
if (!res.ok) throw new Error(...) on the status alone — it never opens the body, so it
has no message to lose and no userMessage to gain. They cannot benefit from an
envelope-reading fix because they do not read the envelope. That is a real and strictly
larger defect, in a different file and outside this card's declared surface, so it is
filed separately as #7959 rather than ridden in here.

fetchFullPackage itself has exactly two callers, and both change:

  • The management sheet behind "Package info & settings" (openManage) — a marked
    refusal now reaches the author through this surface's existing objectui#7368 toast on
    the shared studio-package-list sonner id.
  • The managed-snapshot refresh (onManageChanged's tail, objectui#7907) — the marked
    sentence travels inside the existing engine.studio.pkg.manageRefreshFailed message.
    Same toast, same shared sonner id, same formatter: one mechanism, not a second. §5
    of the new pin file drives this caller specifically so it is shown, not assumed.

The tail's own behaviour (report, then dismiss the panel) belongs to objectui#7907 and is untouched
here; this PR changes only the words it carries.

Evidence

Reverse validation, direction predicted before running: with the fix reverted (mutation
proven on disk by occurrence count and a blob hash differing from HEAD; restored via
git checkout HEAD -- ... under a trap and proven back by an empty git diff HEAD plus a
blob hash identical to the HEAD blob 79a9cf7f0edc1c40b2c8ffa862b3393484bbed37):

Test Files  1 failed (1)
     Tests  7 failed | 8 passed (15)

The prediction was 8 red; 7 measured. The difference is recorded rather than papered over:
§2's "reports once, on the shared sonner id" asserts the report channel, not the words,
and reverting the read changes only the words. It is annotated in the file as green either
way, and it still does a job no words-pin does — a fix that reached for a second reporting
channel would pass every words-pin and fail that one.

Green on the shipping tree:

check result
new pin file Test Files 1 passed, Tests 15 passed
whole studio-design directory (52 files) Test Files 52 passed, Tests 299 passed
the 6 out-of-directory tests that exercise this module Test Files 6 passed, Tests 41 passed
@object-ui/app-shell type-check exit 0, 0 error TS
@object-ui/app-shell lint exit 0, 0 errors over 1082 files
check:control-bytes · check:changeset-presence · check:changeset-no-major · check:vi-mock-specifiers · check:vi-mock-inherit · check:i18n-keys · check:unreferenced-sources · check:entry-guard all exit 0

Every vitest run reports RUN v4.1.10 /home/user/objectui-issue-7938 — the repository
root, not a package sub-root (objectui#3378's silent-false-green). Type-check non-vacuity
was proven with tsc -p tsconfig.test.json --listFiles: both edited files are in the
4416-file program. Lint narrowing needed no argument — the whole package was scanned, and
both edited files are present in eslint's own 1082-file population with 0 errors and no
new warnings (the changed file's 19 warnings are all pre-existing, none within the added
line range 401–449).

check:eager-closure-budget is NOT MEASURED, not green: it exits 2 declaring its own
prerequisite unmet ("No eager-closure report at apps/console/dist/eager-closure.json, so
no chunk was weighed... This is a broken gauge, not 4 budgets that all passed"). It needs a
console build; CI owns that run.

Clause-② = no

多读一个已经在线上的响应字段并渲染它:不改任何 schema 的接受/拒绝行为,不扩大公开面,不移动任何门的扫描 population。


Generated by Claude Code

…the Studio package lookup

`fetchFullPackage` reads the ADR-0112 failure envelope for the words it shows
the author, and read only `error.message` and `error.code`. The platform's
package door also emits `error.userMessage` — the text a producer marked AT
THROW TIME as addressed to the end user, whose presence IS the marking and
which a consumer that sees it is meant to render verbatim.

The two compose badly in the 5xx band. That door withholds the producer's PROSE
and substitutes the generic `Internal server error` into `message`, but the
withhold rewrites a local `message` const and the leak predicate is only ever
handed `thrown.message`, so `userMessage` is never an input to it and rides
through a sanitised 500 untouched. An author meeting a marked 500/503 was shown
the generic sentence and the specific one silently had nowhere to appear.

Prefer the mark over the diagnostic, at any status — the producing door applies
no status condition to this channel, so honouring it in one band only would
re-create on the reading end the divergence that door refused to create on the
writing end. `code` still travels with whichever prose won, and an unmarked
refusal (no `userMessage`, or a non-string one) falls through to `message` with
byte-identical output.

The read is shared, so both of its callers benefit: the management sheet behind
"Package info & settings" and the managed-snapshot refresh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
…, measured

The ablation run (fix reverted) turned 7 of the 15 pins red, not the 8 the
file's header implied. The one that stayed green is §2's `reports once, on the
shared sonner id`: it asserts the report CHANNEL — objectui#7368's posture of
one outage, one toast, one id — and reverting the read changes only the WORDS.

Annotated rather than strengthened. It is doing a real job that none of the
words-pins do: a fix that reached for a second reporting channel to carry the
marked text would satisfy every words-pin and fail this one. Recording the
measured colour so the header does not overclaim which pins discriminate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3189.5 KB 3191.4 KB
Main entry chunk (gzip) 143.6 KB 350 KB
Entry file index-DhTWU0Sv.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 510.60KB 116.20KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 182.08KB 50.62KB
fields (index.js) 242.44KB 61.25KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 47.87KB 13.31KB
plugin-charts (index.js) 70.92KB 19.75KB
plugin-chatbot (index.js) 196.19KB 46.37KB
plugin-dashboard (index.js) 132.88KB 34.69KB
plugin-designer (index.js) 212.86KB 43.19KB
plugin-detail (index.js) 250.55KB 64.06KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.87KB 32.66KB
plugin-gantt (index.js) 167.26KB 41.00KB
plugin-grid (index.js) 209.29KB 56.78KB
plugin-kanban (index.js) 52.71KB 14.55KB
plugin-list (index.js) 113.56KB 27.70KB
plugin-map (index.js) 20.44KB 6.78KB
plugin-markdown (index.js) 13.93KB 4.81KB
plugin-report (index.js) 43.59KB 11.97KB
plugin-timeline (index.js) 30.40KB 8.76KB
plugin-tree (index.js) 9.20KB 3.19KB
plugin-view (index.js) 85.24KB 20.94KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(app-shell): fetchFullPackage drops the envelope's producer-marked userMessage, so a 5xx shows the generic sentence instead

2 participants